--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 926b9c58baaf060c5d225fa0a081d76689c458e5
Parents : 64a5857
Author : Ivan <e46112d44649266d71fe2193e00a4710>
Signature : T66BB85Valid, signed by author
Date : 2026-07-17T20:28:36-05:00
feat: implement desktop privacy settings with screen security options for Windows
Changes
31 files changed, 1556 insertions(+), 16 deletions(-)
Diff
diff --git a/electron/desktopPrivacySettings.js b/electron/desktopPrivacySettings.js
new file mode 100644
index 00000000..7be89c4a
--- /dev/null
+++ b/electron/desktopPrivacySettings.js
@@ -0,0 +1,120 @@
+// SPDX-License-Identifier: 0BSD
+
+const fs = require("fs");
+const path = require("node:path");
+
+function defaultDesktopPrivacySettings() {
+ return {
+ screenSecurityEnabled: false,
+ };
+}
+
+function desktopPrivacySettingsPath(storageDir) {
+ return path.join(storageDir, "desktop-privacy-settings.json");
+}
+
+function normalizeDesktopPrivacySettings(raw) {
+ const defaults = defaultDesktopPrivacySettings();
+ if (!raw || typeof raw !== "object") {
+ return defaults;
+ }
+ return {
+ screenSecurityEnabled:
+ typeof raw.screenSecurityEnabled === "boolean"
+ ? raw.screenSecurityEnabled
+ : defaults.screenSecurityEnabled,
+ };
+}
+
+function loadDesktopPrivacySettings(storageDir) {
+ try {
+ const filePath = desktopPrivacySettingsPath(storageDir);
+ if (!fs.existsSync(filePath)) {
+ return defaultDesktopPrivacySettings();
+ }
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
+ return normalizeDesktopPrivacySettings(parsed);
+ } catch {
+ return defaultDesktopPrivacySettings();
+ }
+}
+
+function saveDesktopPrivacySettings(storageDir, partial) {
+ const current = loadDesktopPrivacySettings(storageDir);
+ const next = normalizeDesktopPrivacySettings({
+ ...current,
+ ...(partial && typeof partial === "object" ? partial : {}),
+ });
+ try {
+ fs.mkdirSync(storageDir, { recursive: true });
+ fs.writeFileSync(desktopPrivacySettingsPath(storageDir), JSON.stringify(next, null, 2), "utf8");
+ } catch {
+ // ignore persistence failures; in-memory choice still applies for this session
+ }
+ return next;
+}
+
+/**
+ * Windows is the primary target (Recall / capture pipelines).
+ * Electron also supports content protection on macOS.
+ * @param {string} platform
+ * @returns {boolean}
+ */
+function isScreenSecurityPlatformSupported(platform) {
+ return platform === "win32" || platform === "darwin";
+}
+
+/**
+ * Apply content protection to a BrowserWindow-like object.
+ * Safe against destroyed windows and missing APIs.
+ * @param {{ isDestroyed?: () => boolean, setContentProtection?: (enabled: boolean) => void } | null | undefined} windowLike
+ * @param {boolean} enabled
+ * @returns {boolean} true when protection was applied
+ */
+function applyContentProtection(windowLike, enabled) {
+ if (!windowLike) {
+ return false;
+ }
+ if (typeof windowLike.isDestroyed === "function" && windowLike.isDestroyed()) {
+ return false;
+ }
+ if (typeof windowLike.setContentProtection !== "function") {
+ return false;
+ }
+ try {
+ windowLike.setContentProtection(enabled === true);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Apply content protection across a list of windows.
+ * @param {Array<{ isDestroyed?: () => boolean, setContentProtection?: (enabled: boolean) => void }>} windows
+ * @param {boolean} enabled
+ * @returns {number} number of windows updated
+ */
+function applyContentProtectionToWindows(windows, enabled) {
+ if (!Array.isArray(windows)) {
+ return 0;
+ }
+ let applied = 0;
+ for (const win of windows) {
+ if (applyContentProtection(win, enabled)) {
+ applied += 1;
+ }
+ }
+ return applied;
+}
+
+module.exports = {
+ defaultDesktopPrivacySettings,
+ normalizeDesktopPrivacySettings,
+ loadDesktopPrivacySettings,
+ saveDesktopPrivacySettings,
+ isScreenSecurityPlatformSupported,
+ desktopPrivacySettingsPath,
+ applyContentProtection,
+ applyContentProtectionToWindows,
+};
diff --git a/electron/main.js b/electron/main.js
index e8c1029c..a1215829 100644
--- a/electron/main.js
+++ b/electron/main.js
@@ -35,10 +35,18 @@ const {
rememberedCloseSettings,
createCloseRequestGuard,
} = require("./closeBehavior");
+const {
+ loadDesktopPrivacySettings,
+ saveDesktopPrivacySettings,
+ isScreenSecurityPlatformSupported,
+ applyContentProtection,
+ applyContentProtectionToWindows,
+} = require("./desktopPrivacySettings");
// remember main window
var mainWindow = null;
var closeSettings = null;
+var desktopPrivacySettings = null;
var closeRequestGuard = createCloseRequestGuard();
function getDialogParentWindow() {
@@ -375,6 +383,14 @@ ipcMain.handle("set-close-settings", (_event, partial) => {
return updateCloseSettings(partial || {});
});
+ipcMain.handle("get-screen-security-settings", () => {
+ return getScreenSecuritySettingsPayload();
+});
+
+ipcMain.handle("set-screen-security-enabled", (_event, enabled) => {
+ return updateScreenSecurityEnabled(enabled === true);
+});
+
ipcMain.handle("get-memory-usage", async () => {
return process.getProcessMemoryInfo();
});
@@ -746,6 +762,42 @@ function updateCloseSettings(partial) {
return closeSettings;
}
+function getDesktopPrivacySettings() {
+ if (!desktopPrivacySettings) {
+ desktopPrivacySettings = loadDesktopPrivacySettings(getDefaultStorageDir());
+ }
+ return desktopPrivacySettings;
+}
+
+function getScreenSecuritySettingsPayload() {
+ const settings = getDesktopPrivacySettings();
+ const platform = process.platform;
+ return {
+ platform,
+ available: isScreenSecurityPlatformSupported(platform),
+ windowsDrm: platform === "win32",
+ enabled: settings.screenSecurityEnabled === true,
+ };
+}
+
+function applyScreenSecurityToWindow(browserWindow, enabled) {
+ if (!applyContentProtection(browserWindow, enabled === true)) {
+ return;
+ }
+}
+
+function applyScreenSecurityToAllWindows(enabled) {
+ applyContentProtectionToWindows(BrowserWindow.getAllWindows(), enabled === true);
+}
+
+function updateScreenSecurityEnabled(enabled) {
+ desktopPrivacySettings = saveDesktopPrivacySettings(getDefaultStorageDir(), {
+ screenSecurityEnabled: enabled === true,
+ });
+ applyScreenSecurityToAllWindows(desktopPrivacySettings.screenSecurityEnabled);
+ return getScreenSecuritySettingsPayload();
+}
+
function destroyTray() {
if (tray && !tray.isDestroyed()) {
tray.destroy();
@@ -868,6 +920,8 @@ app.whenReady().then(async () => {
attachDevToolsF12Shortcut(browserWindow);
attachWindowOpenHandler(browserWindow);
attachInWindowNavigationGuard(browserWindow);
+ const privacy = getDesktopPrivacySettings();
+ applyScreenSecurityToWindow(browserWindow, privacy.screenSecurityEnabled === true);
});
// Security: Enforce CSP for all requests as a shell-level fallback
diff --git a/electron/preload.js b/electron/preload.js
index 9ef5f849..494e0a1c 100644
--- a/electron/preload.js
+++ b/electron/preload.js
@@ -78,6 +78,18 @@ contextBridge.exposeInMainWorld("electron", {
return await ipcRenderer.invoke("set-close-settings", partial);
},
+ getPlatform: function () {
+ return process.platform;
+ },
+
+ getScreenSecuritySettings: async function () {
+ return await ipcRenderer.invoke("get-screen-security-settings");
+ },
+
+ setScreenSecurityEnabled: async function (enabled) {
+ return await ipcRenderer.invoke("set-screen-security-enabled", enabled === true);
+ },
+
// allow getting memory usage in electron browser window
getMemoryUsage: async function () {
return await ipcRenderer.invoke("get-memory-usage");
diff --git a/meshchatx.rsm b/meshchatx.rsm
index a4f0e602..29960c37 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ
diff --git a/meshchatx/src/frontend/components/TutorialModal.vue b/meshchatx/src/frontend/components/TutorialModal.vue
index bd2dd25c..e99bb0f3 100644
--- a/meshchatx/src/frontend/components/TutorialModal.vue
+++ b/meshchatx/src/frontend/components/TutorialModal.vue
@@ -1040,10 +1040,15 @@
</div>
</div>
- <!-- Step 7: Finish -->
+ <!-- Step 7: Privacy -->
+ <div v-else-if="currentStep === 7" key="step7-privacy" class="space-y-4">
+ <TutorialPrivacyStep />
+ </div>
+
+ <!-- Step 8: Finish -->
<div
- v-else-if="currentStep === 7"
- key="step7-finish"
+ v-else-if="currentStep === 8"
+ key="step8-finish"
class="flex flex-col items-center text-center space-y-8 py-10"
>
<div class="w-32 h-32 bg-green-500/10 rounded-full flex items-center justify-center relative">
@@ -2198,10 +2203,15 @@
</div>
</div>
- <!-- Step 7: Finish -->
+ <!-- Step 7: Privacy -->
+ <div v-else-if="currentStep === 7" key="page-step7-privacy" class="space-y-6 py-8">
+ <TutorialPrivacyStep />
+ </div>
+
+ <!-- Step 8: Finish -->
<div
- v-else-if="currentStep === 7"
- key="page-step7-finish"
+ v-else-if="currentStep === 8"
+ key="page-step8-finish"
class="flex flex-col items-center text-center space-y-10 py-20"
>
<div class="w-48 h-48 bg-green-500/10 rounded-full flex items-center justify-center relative">
@@ -2291,6 +2301,7 @@ import { bundledReticulumDocsUrl } from "../js/reticulumDocsEntryUrl.js";
import LanguageSelector from "./LanguageSelector.vue";
import MaterialDesignIcon from "./MaterialDesignIcon.vue";
import Toggle from "./forms/Toggle.vue";
+import TutorialPrivacyStep from "./TutorialPrivacyStep.vue";
export default {
name: "TutorialModal",
@@ -2298,12 +2309,13 @@ export default {
LanguageSelector,
MaterialDesignIcon,
Toggle,
+ TutorialPrivacyStep,
},
data() {
return {
visible: false,
currentStep: 1,
- totalSteps: 7,
+ totalSteps: 8,
logoUrl,
identityMode: "new",
identityName: "",
diff --git a/meshchatx/src/frontend/components/TutorialPrivacyStep.vue b/meshchatx/src/frontend/components/TutorialPrivacyStep.vue
new file mode 100644
index 00000000..660bb169
--- /dev/null
+++ b/meshchatx/src/frontend/components/TutorialPrivacyStep.vue
@@ -0,0 +1,276 @@
+<!-- SPDX-License-Identifier: 0BSD -->
+<template>
+ <div class="space-y-6" :class="compact ? '' : 'py-4'">
+ <div class="space-y-2 text-center sm:text-left">
+ <h2 class="text-2xl sm:text-3xl font-black text-gray-900 dark:text-white">
+ {{ $t("tutorial.privacy_title") }}
+ </h2>
+ <p class="text-base text-gray-600 dark:text-zinc-400 max-w-xl">
+ {{ $t("tutorial.privacy_desc") }}
+ </p>
+ </div>
+
+ <div class="space-y-3 w-full max-w-xl mx-auto sm:mx-0">
+ <div
+ v-if="showWindowsScreenSecurity"
+ class="p-4 rounded-2xl border border-amber-200 dark:border-amber-900/50 bg-amber-50/90 dark:bg-amber-950/40 text-left space-y-3"
+ >
+ <div class="font-semibold text-amber-950 dark:text-amber-100">
+ {{ $t("app.screen_security_drm_eyebrow") }}
+ </div>
+ <p class="text-sm text-amber-950/90 dark:text-amber-100/90">
+ {{ $t("app.screen_security_description") }}
+ </p>
+ <p class="text-xs text-amber-950/80 dark:text-amber-100/80">
+ {{ $t("app.screen_security_drm_note") }}
+ </p>
+ <label class="setting-toggle">
+ <Toggle
+ id="tutorial-screen-security"
+ v-model="screenSecurityEnabled"
+ :disabled="screenSecuritySaving"
+ @update:model-value="onScreenSecurityChange"
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{ $t("app.screen_security_enabled") }}</span>
+ <span class="setting-toggle__description">{{
+ $t("app.screen_security_description_short")
+ }}</span>
+ </span>
+ </label>
+ </div>
+
+ <label v-if="showAndroidScreenshotBlock" class="setting-toggle">
+ <Toggle
+ id="tutorial-android-block-screenshots"
+ v-model="androidBlockScreenshots"
+ :disabled="androidSaving"
+ @update:model-value="onAndroidBlockScreenshotsChange"
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{ $t("settings.android_block_screenshots") }}</span>
+ <span class="setting-toggle__description">{{
+ $t("settings.android_block_screenshots_desc")
+ }}</span>
+ </span>
+ </label>
+
+ <label class="setting-toggle">
+ <Toggle
+ id="tutorial-obfuscate-hops"
+ v-model="localHopsDelta"
+ :disabled="reticulumSaving"
+ @update:model-value="onLocalHopsDeltaChange"
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{ $t("app.obfuscate_hops") }}</span>
+ <span class="setting-toggle__description">{{ $t("app.obfuscate_hops_description") }}</span>
+ </span>
+ </label>
+
+ <label class="setting-toggle">
+ <Toggle
+ id="tutorial-privacy-mode"
+ v-model="privacyModeEnabled"
+ :disabled="configSaving"
+ @update:model-value="onPrivacyModeChange"
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{ $t("app.privacy_mode_enabled") }}</span>
+ <span class="setting-toggle__description">{{ $t("app.privacy_mode_description") }}</span>
+ </span>
+ </label>
+
+ <label class="setting-toggle">
+ <Toggle
+ id="tutorial-telemetry"
+ v-model="telemetryEnabled"
+ :disabled="configSaving"
+ @update:model-value="onTelemetryChange"
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{ $t("app.telemetry_enabled") }}</span>
+ <span class="setting-toggle__description">{{ $t("app.telemetry_description") }}</span>
+ </span>
+ </label>
+ </div>
+
+ <p class="text-xs text-center sm:text-left text-gray-500 dark:text-zinc-500 max-w-xl">
+ {{ $t("tutorial.privacy_later_hint") }}
+ </p>
+ </div>
+</template>
+
+<script>
+import ElectronUtils from "../js/ElectronUtils.js";
+import AndroidBridge from "../js/rnode/AndroidBridge.js";
+import ToastUtils from "../js/ToastUtils";
+import Toggle from "./forms/Toggle.vue";
+
+export default {
+ name: "TutorialPrivacyStep",
+ components: {
+ Toggle,
+ },
+ props: {
+ compact: {
+ type: Boolean,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ screenSecurityEnabled: false,
+ screenSecuritySaving: false,
+ showWindowsScreenSecurity: false,
+ androidBlockScreenshots: false,
+ androidSaving: false,
+ showAndroidScreenshotBlock: false,
+ localHopsDelta: false,
+ reticulumSaving: false,
+ privacyModeEnabled: false,
+ telemetryEnabled: false,
+ configSaving: false,
+ };
+ },
+ async mounted() {
+ await this.loadPrivacyOptions();
+ },
+ methods: {
+ async loadPrivacyOptions() {
+ this.showWindowsScreenSecurity =
+ typeof ElectronUtils.isWindowsElectron === "function" && ElectronUtils.isWindowsElectron();
+ this.showAndroidScreenshotBlock =
+ typeof window !== "undefined" &&
+ window.MeshChatXAndroid &&
+ typeof window.MeshChatXAndroid.getPlatform === "function" &&
+ window.MeshChatXAndroid.getPlatform() === "android";
+
+ if (this.showWindowsScreenSecurity) {
+ try {
+ const settings = await ElectronUtils.getScreenSecuritySettings();
+ this.screenSecurityEnabled = settings?.enabled === true;
+ } catch {
+ this.screenSecurityEnabled = false;
+ }
+ }
+
+ if (this.showAndroidScreenshotBlock) {
+ try {
+ const bridge = new AndroidBridge();
+ this.androidBlockScreenshots = bridge.getBlockScreenshots() === true;
+ } catch {
+ this.androidBlockScreenshots = false;
+ }
+ }
+
+ try {
+ const [configResponse, instanceResponse] = await Promise.all([
+ window.api.get("/api/v1/config"),
+ window.api.get("/api/v1/reticulum/instance"),
+ ]);
+ const config = configResponse?.data?.config || {};
+ this.privacyModeEnabled = config.privacy_mode_enabled === true;
+ this.telemetryEnabled = config.telemetry_enabled === true;
+ this.localHopsDelta = instanceResponse?.data?.instance?.local_hops_delta === true;
+ } catch (e) {
+ console.error("Failed to load tutorial privacy options:", e);
+ }
+ },
+ async onScreenSecurityChange(value) {
+ if (this.screenSecuritySaving) {
+ return;
+ }
+ this.screenSecuritySaving = true;
+ try {
+ const settings = await ElectronUtils.setScreenSecurityEnabled(value === true);
+ this.screenSecurityEnabled = settings?.enabled === true;
+ ToastUtils.success(
+ this.screenSecurityEnabled
+ ? this.$t("app.screen_security_enabled_toast")
+ : this.$t("app.screen_security_disabled_toast")
+ );
+ } catch (e) {
+ console.error(e);
+ this.screenSecurityEnabled = !value;
+ ToastUtils.error(this.$t("common.save_failed"));
+ } finally {
+ this.screenSecuritySaving = false;
+ }
+ },
+ async onAndroidBlockScreenshotsChange(value) {
+ if (this.androidSaving) {
+ return;
+ }
+ this.androidSaving = true;
+ try {
+ const bridge = new AndroidBridge();
+ if (!bridge.setBlockScreenshots(value === true)) {
+ throw new Error("setBlockScreenshots failed");
+ }
+ this.androidBlockScreenshots = value === true;
+ } catch (e) {
+ console.error(e);
+ this.androidBlockScreenshots = !value;
+ ToastUtils.error(this.$t("common.save_failed"));
+ } finally {
+ this.androidSaving = false;
+ }
+ },
+ async onLocalHopsDeltaChange(value) {
+ if (this.reticulumSaving) {
+ return;
+ }
+ this.reticulumSaving = true;
+ try {
+ await window.api.patch("/api/v1/reticulum/instance", {
+ local_hops_delta: value === true,
+ });
+ this.localHopsDelta = value === true;
+ } catch (e) {
+ console.error(e);
+ this.localHopsDelta = !value;
+ ToastUtils.error(this.$t("settings.failed_update_reticulum_instance"));
+ } finally {
+ this.reticulumSaving = false;
+ }
+ },
+ async onPrivacyModeChange(value) {
+ if (this.configSaving) {
+ return;
+ }
+ this.configSaving = true;
+ try {
+ await window.api.patch("/api/v1/config", {
+ privacy_mode_enabled: value === true,
+ });
+ this.privacyModeEnabled = value === true;
+ } catch (e) {
+ console.error(e);
+ this.privacyModeEnabled = !value;
+ ToastUtils.error(this.$t("common.save_failed"));
+ } finally {
+ this.configSaving = false;
+ }
+ },
+ async onTelemetryChange(value) {
+ if (this.configSaving) {
+ return;
+ }
+ this.configSaving = true;
+ try {
+ await window.api.patch("/api/v1/config", {
+ telemetry_enabled: value === true,
+ });
+ this.telemetryEnabled = value === true;
+ } catch (e) {
+ console.error(e);
+ this.telemetryEnabled = !value;
+ ToastUtils.error(this.$t("common.save_failed"));
+ } finally {
+ this.configSaving = false;
+ }
+ },
+ },
+};
+</script>
diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue
index 1143c730..ae93ba39 100644
--- a/meshchatx/src/frontend/components/settings/SettingsPage.vue
+++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue
@@ -2815,6 +2815,33 @@
>
{{ $t("app.privacy_eyebrow") }}
</div>
+ <div
+ v-if="showWindowsScreenSecurity"
+ class="p-4 rounded-2xl border border-amber-200 dark:border-amber-900/40 bg-amber-50/80 dark:bg-amber-950/30 space-y-3"
+ >
+ <div class="text-xs font-semibold uppercase tracking-wider text-amber-800 dark:text-amber-200">
+ {{ $t("app.screen_security_drm_eyebrow") }}
+ </div>
+ <label class="setting-toggle">
+ <Toggle
+ id="screen-security-enabled"
+ v-model="screenSecurityEnabled"
+ :disabled="screenSecuritySaving"
+ @update:model-value="onScreenSecurityChange"
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{
+ $t("app.screen_security_enabled")
+ }}</span>
+ <span class="setting-toggle__description">{{
+ $t("app.screen_security_description")
+ }}</span>
+ </span>
+ </label>
+ <p class="text-xs text-amber-900/80 dark:text-amber-100/80">
+ {{ $t("app.screen_security_drm_note") }}
+ </p>
+ </div>
<label class="setting-toggle">
<Toggle
id="privacy-mode-enabled"
@@ -3861,6 +3888,9 @@ export default {
closeBehavior: "ask",
trayEnabled: true,
},
+ screenSecurityEnabled: false,
+ screenSecuritySaving: false,
+ showWindowsScreenSecurity: false,
androidShellPrivacy: {
blockScreenshots: false,
clearClipboardOnBackground: false,
@@ -4105,6 +4135,7 @@ export default {
this.loadBatterySaverPrefsFromStorage();
this.loadBatteryInterfaceRows();
this.loadDesktopCloseSettings();
+ this.loadScreenSecuritySettings();
this.loadReticulumInstanceSettings();
this.loadAndroidShellPrivacy();
},
@@ -4341,6 +4372,48 @@ export default {
console.log(e);
}
},
+ async loadScreenSecuritySettings() {
+ this.showWindowsScreenSecurity =
+ typeof ElectronUtils.isWindowsElectron === "function" && ElectronUtils.isWindowsElectron();
+ if (!this.showWindowsScreenSecurity) {
+ return;
+ }
+ try {
+ const settings = await ElectronUtils.getScreenSecuritySettings();
+ this.screenSecurityEnabled = settings?.enabled === true;
+ } catch (e) {
+ console.log(e);
+ }
+ },
+ async onScreenSecurityChange(value) {
+ if (this.screenSecuritySaving) {
+ return;
+ }
+ this.screenSecuritySaving = true;
+ const enabled = value === true;
+ try {
+ if (!enabled) {
+ const confirmed = await DialogUtils.confirm(this.$t("app.screen_security_disable_confirm"));
+ if (!confirmed) {
+ this.screenSecurityEnabled = true;
+ return;
+ }
+ }
+ const settings = await ElectronUtils.setScreenSecurityEnabled(enabled);
+ this.screenSecurityEnabled = settings?.enabled === true;
+ ToastUtils.success(
+ this.screenSecurityEnabled
+ ? this.$t("app.screen_security_enabled_toast")
+ : this.$t("app.screen_security_disabled_toast")
+ );
+ } catch (e) {
+ console.log(e);
+ this.screenSecurityEnabled = !enabled;
+ ToastUtils.error(this.$t("common.save_failed"));
+ } finally {
+ this.screenSecuritySaving = false;
+ }
+ },
async onDesktopTrayEnabledChange(value) {
this.desktopCloseSettings.trayEnabled = value === true;
try {
diff --git a/meshchatx/src/frontend/js/ElectronUtils.js b/meshchatx/src/frontend/js/ElectronUtils.js
index e75c868d..4108ce93 100644
--- a/meshchatx/src/frontend/js/ElectronUtils.js
+++ b/meshchatx/src/frontend/js/ElectronUtils.js
@@ -31,6 +31,31 @@ class ElectronUtils {
return null;
}
+ static getPlatform() {
+ if (typeof window.electron?.getPlatform === "function") {
+ return window.electron.getPlatform();
+ }
+ return null;
+ }
+
+ static isWindowsElectron() {
+ return ElectronUtils.isElectron() && ElectronUtils.getPlatform() === "win32";
+ }
+
+ static async getScreenSecuritySettings() {
+ if (window.electron?.getScreenSecuritySettings) {
+ return await window.electron.getScreenSecuritySettings();
+ }
+ return null;
+ }
+
+ static async setScreenSecurityEnabled(enabled) {
+ if (window.electron?.setScreenSecurityEnabled) {
+ return await window.electron.setScreenSecurityEnabled(enabled === true);
+ }
+ return null;
+ }
+
static async getMemoryUsage() {
if (window.electron) {
return await window.electron.getMemoryUsage();
diff --git a/meshchatx/src/frontend/js/registries/corePostInstallPromptEntries.js b/meshchatx/src/frontend/js/registries/corePostInstallPromptEntries.js
index 8462d44d..20450e16 100644
--- a/meshchatx/src/frontend/js/registries/corePostInstallPromptEntries.js
+++ b/meshchatx/src/frontend/js/registries/corePostInstallPromptEntries.js
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: 0BSD
+import ElectronUtils from "../ElectronUtils.js";
+
/**
* Core post-install / existing-user prompts.
*
@@ -9,13 +11,36 @@
* @type {import('./postInstallPromptRegistry.js').PostInstallPromptEntry[]}
*/
export const CORE_POST_INSTALL_PROMPT_ENTRIES = [
- // Example:
- // {
- // id: "example_notice",
- // revision: 1,
- // priority: 10,
- // titleKey: "post_install.example_title",
- // descriptionKey: "post_install.example_desc",
- // primaryLabelKey: "common.got_it",
- // },
+ {
+ id: "windows_screen_security",
+ revision: 1,
+ priority: 40,
+ titleKey: "post_install.windows_screen_security_title",
+ descriptionKey: "post_install.windows_screen_security_desc",
+ primaryLabelKey: "post_install.windows_screen_security_enable",
+ secondaryLabelKey: "post_install.windows_screen_security_later",
+ async shouldShow() {
+ if (typeof ElectronUtils.isWindowsElectron !== "function" || !ElectronUtils.isWindowsElectron()) {
+ return false;
+ }
+ try {
+ const settings = await ElectronUtils.getScreenSecuritySettings();
+ if (!settings?.available) {
+ return false;
+ }
+ return settings.enabled !== true;
+ } catch {
+ return false;
+ }
+ },
+ async onPrimary() {
+ try {
+ await ElectronUtils.setScreenSecurityEnabled(true);
+ return true;
+ } catch (e) {
+ console.error("Failed to enable Windows screen security:", e);
+ return false;
+ }
+ },
+ },
];
diff --git a/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js b/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js
index 59b6f8e9..54be8762 100644
--- a/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js
+++ b/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js
@@ -380,6 +380,12 @@ export const CORE_SETTINGS_SECTION_KEYWORDS = {
"app.privacy_data_description",
"app.privacy_mode_enabled",
"app.privacy_mode_description",
+ "app.screen_security_enabled",
+ "app.screen_security_description",
+ "app.screen_security_drm_eyebrow",
+ "DRM",
+ "Recall",
+ "screenshot",
"app.obfuscate_hops",
"app.obfuscate_hops_description",
"app.local_message_auto_delete_title",
diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index 7361c445..a7bfb658 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -138,6 +138,14 @@
"privacy_subsection_telemetry": "Mesh-Telemetrie",
"privacy_mode_enabled": "Datenschutzmodus (externes HTTP/HTTPS blockieren)",
"privacy_mode_description": "Blockiert Kartenkacheln, Geocoding, LibreTranslate, Firmware-Downloads und andere ausgehende HTTP/HTTPS-Verbindungen der App. Die Content Security Policy des Browsers wird auf Same-Origin beschränkt.",
+ "screen_security_enabled": "Bildschirmsicherheit (Aufnahme blockieren)",
+ "screen_security_description": "Verwendet ein Windows-Display-Affinity-Flag, damit MeshChatX in Screenshots, Bildschirmaufnahmen und Windows Recall ausgelassen wird. Das Fenster sieht auf Ihrem Monitor weiterhin normal aus.",
+ "screen_security_description_short": "Dieses Fenster vor Screenshots, Recordern und Windows Recall verbergen.",
+ "screen_security_drm_eyebrow": "Windows-DRM / Bildschirmaufnahme-Schutz",
+ "screen_security_drm_note": "Dies nutzt dasselbe Betriebssystem-Flag zum Inhaltsschutz wie Medien-Apps für DRM. Es ist keine harte Garantie: eine Kamera auf den Bildschirm kann weiterhin Inhalte erfassen, und manche Hilfsmittel benötigen diese Einstellung ausgeschaltet.",
+ "screen_security_disable_confirm": "Bildschirmsicherheit ausschalten? Windows Recall und andere Apps können MeshChatX dann wieder erfassen.",
+ "screen_security_enabled_toast": "Bildschirmsicherheit aktiviert",
+ "screen_security_disabled_toast": "Bildschirmsicherheit deaktiviert",
"web_exposure_title": "Netzwerkexposition",
"web_exposure_description": "Die Bind-Adresse der Web-UI wird beim Start über --host oder MESHCHAT_HOST festgelegt. Beschränken Sie den Zugriff, wenn nicht an Loopback gebunden.",
"web_listen_address": "Aktuelle Bind-Adresse",
@@ -3070,6 +3078,9 @@
"ready": "Bereit zum Loslegen!",
"ready_desc": "Alles ist eingerichtet. Reticulum wurde mit Ihrer neuen Konfiguration neu gestartet.",
"ready_desc_page": "MeshChatX ist konfiguriert und Reticulum wurde mit Ihren neuen Einstellungen heiß neu geladen.",
+ "privacy_title": "Datenschutz und Sicherheit",
+ "privacy_desc": "Optionale Härtung für dieses Gerät. Sie können das jederzeit unter Einstellungen → Datenschutz ändern.",
+ "privacy_later_hint": "Empfohlen bei sensibler Nutzung. Optionen auszulassen ist für normale Installationen in Ordnung.",
"docker_note": "Reticulum wurde automatisch neu gestartet. Falls etwas nicht stimmt, können Sie MeshChatX über das Menü neu starten.",
"restart_required": "Reticulum neu gestartet",
"restart_desc_page": "Reticulum wurde mit Ihrer neuen Konfiguration heiß neu geladen. Im Normalbetrieb ist kein manueller Neustart erforderlich.",
@@ -3735,5 +3746,11 @@
"identity_created": "Management identity created",
"failed_load_identities": "Failed to load management identities",
"failed_create_identity": "Failed to create management identity"
+ },
+ "post_install": {
+ "windows_screen_security_title": "Windows-Bildschirmsicherheit aktivieren?",
+ "windows_screen_security_desc": "MeshChatX kann den Windows-Inhaltsschutz nutzen (dasselbe DRM-ähnliche Display-Flag wie Medien-Apps), damit Screenshots, Recorder und Windows Recall das App-Fenster nicht erfassen. Später änderbar unter Einstellungen → Datenschutz.",
+ "windows_screen_security_enable": "Bildschirmsicherheit aktivieren",
+ "windows_screen_security_later": "Nicht jetzt"
}
}
diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index 1a030afb..73a4d75f 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -194,6 +194,14 @@
"privacy_subsection_telemetry": "Mesh telemetry",
"privacy_mode_enabled": "Privacy mode (block external HTTP/HTTPS)",
"privacy_mode_description": "Blocks map tiles, geocoding, LibreTranslate, firmware downloads, and other outbound HTTP/HTTPS from the app. The browser Content Security Policy is tightened to same-origin only.",
+ "screen_security_enabled": "Screen security (block capture)",
+ "screen_security_description": "Uses a Windows display-affinity flag so MeshChatX is omitted from screenshots, screen recording, and Windows Recall. The window still looks normal on your monitor.",
+ "screen_security_description_short": "Hide this window from screenshots, recorders, and Windows Recall.",
+ "screen_security_drm_eyebrow": "Windows DRM / screen capture protection",
+ "screen_security_drm_note": "This reuses the same OS content-protection flag media apps use for DRM. It is not a hard guarantee: a camera pointed at the display can still capture content, and some accessibility tools may need this setting off.",
+ "screen_security_disable_confirm": "Turn off screen security? Windows Recall and other apps will be able to capture MeshChatX again.",
+ "screen_security_enabled_toast": "Screen security enabled",
+ "screen_security_disabled_toast": "Screen security disabled",
"web_exposure_title": "Network exposure",
"web_exposure_description": "The web UI bind address is set at startup via --host or MESHCHAT_HOST. Restrict who can reach it when not bound to loopback.",
"web_listen_address": "Current bind address",
@@ -3613,6 +3621,9 @@
"ready": "Ready to Roll!",
"ready_desc": "Everything is set up. Reticulum has been restarted with your new configuration.",
"ready_desc_page": "MeshChatX is now configured and Reticulum has been hot-reloaded with your new settings.",
+ "privacy_title": "Privacy & security",
+ "privacy_desc": "Optional hardening for this device. You can change these any time in Settings → Privacy.",
+ "privacy_later_hint": "Recommended for sensitive use. Leaving options off is fine for casual installs.",
"docker_note": "Reticulum was restarted automatically. If anything looks off, you can restart MeshChatX from the menu.",
"restart_required": "Reticulum Restarted",
"restart_desc_page": "Reticulum was hot-reloaded with your new configuration. No manual restart is required for normal use.",
@@ -3735,5 +3746,11 @@
"action_getting_started_desc": "Open the setup guide",
"action_changelog": "Changelog",
"action_changelog_desc": "Recent changes"
+ },
+ "post_install": {
+ "windows_screen_security_title": "Enable Windows screen security?",
+ "windows_screen_security_desc": "MeshChatX can use Windows content protection (the same DRM-style display flag media apps use) so screenshots, recorders, and Windows Recall cannot capture the app window. You can change this later under Settings → Privacy.",
+ "windows_screen_security_enable": "Enable screen security",
+ "windows_screen_security_later": "Not now"
}
}
diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json
index bcb54f93..a3002127 100644
--- a/meshchatx/src/frontend/locales/es.json
+++ b/meshchatx/src/frontend/locales/es.json
@@ -164,6 +164,14 @@
"privacy_subsection_telemetry": "Telemetría mesh",
"privacy_mode_enabled": "Modo de privacidad (bloquear HTTP/HTTPS externo)",
"privacy_mode_description": "Bloquea teselas de mapa, geocodificación, LibreTranslate, descargas de firmware y otras conexiones HTTP/HTTPS salientes de la app. La política de seguridad de contenido del navegador se restringe solo al mismo origen.",
+ "screen_security_enabled": "Seguridad de pantalla (bloquear captura)",
+ "screen_security_description": "Usa una marca de afinidad de pantalla de Windows para que MeshChatX se omita en capturas, grabaciones y Windows Recall. La ventana sigue viéndose normal en su monitor.",
+ "screen_security_description_short": "Ocultar esta ventana de capturas, grabadoras y Windows Recall.",
+ "screen_security_drm_eyebrow": "DRM de Windows / protección contra captura de pantalla",
+ "screen_security_drm_note": "Reutiliza la misma marca de protección de contenido del SO que usan las apps de medios para DRM. No es una garantía absoluta: una cámara apuntando a la pantalla aún puede capturar contenido, y algunas herramientas de accesibilidad pueden necesitar esta opción desactivada.",
+ "screen_security_disable_confirm": "¿Desactivar la seguridad de pantalla? Windows Recall y otras apps podrán capturar MeshChatX de nuevo.",
+ "screen_security_enabled_toast": "Seguridad de pantalla activada",
+ "screen_security_disabled_toast": "Seguridad de pantalla desactivada",
"web_exposure_title": "Exposición de red",
"web_exposure_description": "La dirección de enlace de la interfaz web se establece al inicio con --host o MESHCHAT_HOST. Restrinja quién puede acceder cuando no esté enlazada a loopback.",
"web_listen_address": "Dirección de enlace actual",
@@ -3315,6 +3323,9 @@
"ready": "¡Listo para Roll!",
"ready_desc": "Todo está preparado. Reticulum ha sido reiniciado con su nueva configuración.",
"ready_desc_page": "MeshChatX está ahora configurado y Reticulum ha sido cargado con su nueva configuración.",
+ "privacy_title": "Privacidad y seguridad",
+ "privacy_desc": "Refuerzo opcional para este dispositivo. Puede cambiarlo en cualquier momento en Ajustes → Privacidad.",
+ "privacy_later_hint": "Recomendado para uso sensible. Dejar las opciones desactivadas está bien en instalaciones habituales.",
"docker_note": "Reticulum fue reiniciado automáticamente. Si algo se ve apagado, puede reiniciar MeshChatX del menú.",
"restart_required": "Reticulum reiniciado",
"restart_desc_page": "Reticulum fue cargado con tu nueva configuración. No se requiere reinicio manual para uso normal.",
@@ -3735,5 +3746,11 @@
"identity_created": "Management identity created",
"failed_load_identities": "Failed to load management identities",
"failed_create_identity": "Failed to create management identity"
+ },
+ "post_install": {
+ "windows_screen_security_title": "¿Activar la seguridad de pantalla de Windows?",
+ "windows_screen_security_desc": "MeshChatX puede usar la protección de contenido de Windows (la misma marca tipo DRM que usan las apps de medios) para que capturas, grabadoras y Windows Recall no capturen la ventana. Puede cambiarlo después en Ajustes → Privacidad.",
+ "windows_screen_security_enable": "Activar seguridad de pantalla",
+ "windows_screen_security_later": "Ahora no"
}
}
diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json
index 0627bfe5..49d58b2b 100644
--- a/meshchatx/src/frontend/locales/fi.json
+++ b/meshchatx/src/frontend/locales/fi.json
@@ -165,6 +165,14 @@
"privacy_subsection_telemetry": "Mesh-verkon käyttötiedot",
"privacy_mode_enabled": "Yksityisyystila (estä ulkoinen HTTP/HTTPS)",
"privacy_mode_description": "Estää karttalaatat, geokoodaukset, LibreTranslate, laiteohjelmistolataukset, ja muut lähtevät HTTP/HTTPS-yhteydet sovelluksesta. Selaimen sisällön turvallisuuskäytäntö (CSP) kiristetään sallimaan vain samasta lähteestä oleva liikenne.",
+ "screen_security_enabled": "Näytön suojaus (estä kaappaus)",
+ "screen_security_description": "Käyttää Windowsin display-affinity-lippua, jotta MeshChatX jätetään pois kuvakaappauksista, näytön tallennuksesta ja Windows Recallista. Ikkuna näyttää edelleen normaalilta näytölläsi.",
+ "screen_security_description_short": "Piilota tämä ikkuna kuvakaappauksilta, tallentimilta ja Windows Recallilta.",
+ "screen_security_drm_eyebrow": "Windowsin DRM / näytön kaappaussuojaus",
+ "screen_security_drm_note": "Käyttää samaa käyttöjärjestelmän sisällönsuojauslippua kuin mediaohjelmat DRM:ään. Ei ole ehdoton tae: kameraan näyttöön suunnattuna voi yhä kaapata sisältöä, ja jotkin saavutettavuustyökalut voivat vaatia asetuksen pois päältä.",
+ "screen_security_disable_confirm": "Poistetaanko näytön suojaus käytöstä? Windows Recall ja muut sovellukset voivat taas kaapata MeshChatX:n.",
+ "screen_security_enabled_toast": "Näytön suojaus käytössä",
+ "screen_security_disabled_toast": "Näytön suojaus pois käytöstä",
"web_exposure_title": "Verkon näkyvyys",
"web_exposure_description": "Verkkokäyttöliittymän sidososoite luodaan käynnistyksessä asettamalla --host tai MESHCHAT_HOST. Rajoita pääsyä, jos osoite ei ole sidottu paluusilmukkaan.",
"web_listen_address": "Nykyinen sidososoite",
@@ -3522,6 +3530,9 @@
"ready": "Valmis aloittamaan!",
"ready_desc": "Kaikki on asetettu. Reticulum on käynnistetty uudelleen uudella kokoonpanollasi.",
"ready_desc_page": "MeshChatX on nyt määritetty ja Reticulum on ladattu uudelleen kuumana uusilla asetuksillasi.",
+ "privacy_title": "Tietosuoja ja turvallisuus",
+ "privacy_desc": "Valinnainen kovennus tälle laitteelle. Voit muuttaa näitä milloin tahansa kohdassa Asetukset → Tietosuoja.",
+ "privacy_later_hint": "Suositellaan arkaluontoiseen käyttöön. Vaihtoehtojen jättäminen pois on ok tavallisissa asennuksissa.",
"docker_note": "Reticulum käynnistettiin automaattisesti uudelleen. Jos jokin näyttää vialta, voit käynnistää MeshChatX:n uudelleen valikosta.",
"restart_required": "Reticulum käynnistettiin uudelleen",
"restart_desc_page": "Reticulum ladattiin uudelleen kuumana uudella kokoonpanollasi. Manuaalista uudelleenkäynnistystä ei tarvita normaalia käyttöä varten.",
@@ -3735,5 +3746,11 @@
"identity_created": "Management identity created",
"failed_load_identities": "Failed to load management identities",
"failed_create_identity": "Failed to create management identity"
+ },
+ "post_install": {
+ "windows_screen_security_title": "Otetaanko Windowsin näytön suojaus käyttöön?",
+ "windows_screen_security_desc": "MeshChatX voi käyttää Windowsin sisällönsuojausta (samaa DRM-tyylistä näyttölippua kuin mediaohjelmat), jotta kuvakaappaukset, tallentimet ja Windows Recall eivät kaappaa sovellusikkunaa. Voit muuttaa tätä myöhemmin kohdassa Asetukset → Tietosuoja.",
+ "windows_screen_security_enable": "Ota näytön suojaus käyttöön",
+ "windows_screen_security_later": "Ei nyt"
}
}
diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json
index eedb38ce..05d0d8ca 100644
--- a/meshchatx/src/frontend/locales/fr.json
+++ b/meshchatx/src/frontend/locales/fr.json
@@ -164,6 +164,14 @@
"privacy_subsection_telemetry": "Télémétrie mesh",
"privacy_mode_enabled": "Mode confidentialité (bloquer HTTP/HTTPS externe)",
"privacy_mode_description": "Bloque les tuiles de carte, le géocodage, LibreTranslate, les téléchargements de firmware et autres connexions HTTP/HTTPS sortantes de l'application. La politique de sécurité du contenu du navigateur est restreinte à same-origin uniquement.",
+ "screen_security_enabled": "Sécurité d’écran (bloquer la capture)",
+ "screen_security_description": "Utilise un indicateur d’affinité d’affichage Windows pour que MeshChatX soit omis des captures d’écran, de l’enregistrement et de Windows Recall. La fenêtre reste normale sur votre moniteur.",
+ "screen_security_description_short": "Masquer cette fenêtre des captures, enregistreurs et de Windows Recall.",
+ "screen_security_drm_eyebrow": "DRM Windows / protection contre la capture d’écran",
+ "screen_security_drm_note": "Réutilise le même indicateur de protection du contenu du système que les apps multimédias pour le DRM. Ce n’est pas une garantie absolue : un appareil photo pointé vers l’écran peut encore capturer le contenu, et certains outils d’accessibilité peuvent nécessiter de désactiver cette option.",
+ "screen_security_disable_confirm": "Désactiver la sécurité d’écran ? Windows Recall et d’autres applications pourront à nouveau capturer MeshChatX.",
+ "screen_security_enabled_toast": "Sécurité d’écran activée",
+ "screen_security_disabled_toast": "Sécurité d’écran désactivée",
"web_exposure_title": "Exposition réseau",
"web_exposure_description": "L'adresse d'écoute de l'interface web est définie au démarrage via --host ou MESHCHAT_HOST. Restreignez l'accès lorsque l'écoute n'est pas sur loopback.",
"web_listen_address": "Adresse d'écoute actuelle",
@@ -3315,6 +3323,9 @@
"ready": "Prêt à rouler !",
"ready_desc": "Tout est arrangé. Reticulum a été redémarré avec votre nouvelle configuration.",
"ready_desc_page": "MeshChatX est maintenant configuré et Reticulum a été chargé à chaud avec vos nouveaux paramètres.",
+ "privacy_title": "Confidentialité et sécurité",
+ "privacy_desc": "Renforcement facultatif pour cet appareil. Vous pouvez changer ces options à tout moment dans Paramètres → Confidentialité.",
+ "privacy_later_hint": "Recommandé pour un usage sensible. Laisser les options désactivées convient aux installations courantes.",
"docker_note": "Le réticulum a été redémarré automatiquement. Si quelque chose vous regarde, vous pouvez redémarrer MeshChatX depuis le menu.",
"restart_required": "Reticulum Recommencé",
"restart_desc_page": "Reticulum a été chargé à chaud avec votre nouvelle configuration. Aucun redémarrage manuel n'est nécessaire pour une utilisation normale.",
@@ -3735,5 +3746,11 @@
"identity_created": "Management identity created",
"failed_load_identities": "Failed to load management identities",
"failed_create_identity": "Failed to create management identity"
+ },
+ "post_install": {
+ "windows_screen_security_title": "Activer la sécurité d’écran Windows ?",
+ "windows_screen_security_desc": "MeshChatX peut utiliser la protection de contenu Windows (le même indicateur d’affichage de type DRM que les apps multimédias) pour que les captures, enregistreurs et Windows Recall ne capturent pas la fenêtre. Modifiable plus tard dans Paramètres → Confidentialité.",
+ "windows_screen_security_enable": "Activer la sécurité d’écran",
+ "windows_screen_security_later": "Pas maintenant"
}
}
diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index 366c69e6..65a90927 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -164,6 +164,14 @@
"privacy_subsection_telemetry": "Telemetria mesh",
"privacy_mode_enabled": "Modalità privacy (blocca HTTP/HTTPS esterni)",
"privacy_mode_description": "Blocca tile mappe, geocodifica, LibreTranslate, download firmware e altre connessioni HTTP/HTTPS in uscita dall'app. La Content Security Policy del browser è ristretta al solo same-origin.",
+ "screen_security_enabled": "Sicurezza schermo (blocca acquisizione)",
+ "screen_security_description": "Usa un flag di affinità display di Windows affinché MeshChatX sia escluso da screenshot, registrazioni e Windows Recall. La finestra resta normale sul monitor.",
+ "screen_security_description_short": "Nascondi questa finestra da screenshot, registratori e Windows Recall.",
+ "screen_security_drm_eyebrow": "DRM Windows / protezione acquisizione schermo",
+ "screen_security_drm_note": "Riutilizza lo stesso flag di protezione contenuti del sistema usato dalle app multimediali per il DRM. Non è una garanzia assoluta: una fotocamera puntata sullo schermo può ancora catturare contenuti, e alcuni strumenti di accessibilità possono richiedere questa opzione disattivata.",
+ "screen_security_disable_confirm": "Disattivare la sicurezza dello schermo? Windows Recall e altre app potranno di nuovo acquisire MeshChatX.",
+ "screen_security_enabled_toast": "Sicurezza schermo attivata",
+ "screen_security_disabled_toast": "Sicurezza schermo disattivata",
"web_exposure_title": "Esposizione di rete",
"web_exposure_description": "L'indirizzo di bind dell'interfaccia web è impostato all'avvio tramite --host o MESHCHAT_HOST. Limita chi può accedervi quando non è in ascolto su loopback.",
"web_listen_address": "Indirizzo di bind attuale",
@@ -3315,6 +3323,9 @@
"ready": "Pronto per Iniziare!",
"ready_desc": "Tutto è configurato. Reticulum è stato riavviato con la nuova configurazione.",
"ready_desc_page": "MeshChatX è ora configurato e Reticulum è stato ricaricato a caldo con le nuove impostazioni.",
+ "privacy_title": "Privacy e sicurezza",
+ "privacy_desc": "Rafforzamento facoltativo per questo dispositivo. Puoi modificarlo in qualsiasi momento in Impostazioni → Privacy.",
+ "privacy_later_hint": "Consigliato per usi sensibili. Lasciare le opzioni disattivate va bene per installazioni comuni.",
"docker_note": "Reticulum è stato riavviato automaticamente. Se qualcosa non va, riavvia MeshChatX dal menu.",
"restart_required": "Reticulum riavviato",
"restart_desc_page": "Reticulum è stato ricaricato a caldo con la nuova configurazione. Per l'uso normale non è richiesto un riavvio manuale.",
@@ -3735,5 +3746,11 @@
"identity_created": "Management identity created",
"failed_load_identities": "Failed to load management identities",
"failed_create_identity": "Failed to create management identity"
+ },
+ "post_install": {
+ "windows_screen_security_title": "Attivare la sicurezza schermo di Windows?",
+ "windows_screen_security_desc": "MeshChatX può usare la protezione contenuti di Windows (lo stesso flag display in stile DRM delle app multimediali) così screenshot, registratori e Windows Recall non catturano la finestra. Modificabile in seguito in Impostazioni → Privacy.",
+ "windows_screen_security_enable": "Attiva sicurezza schermo",
+ "windows_screen_security_later": "Non ora"
}
}
diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json
index 67886e38..38a08215 100644
--- a/meshchatx/src/frontend/locales/nl.json
+++ b/meshchatx/src/frontend/locales/nl.json
@@ -164,6 +164,14 @@
"privacy_subsection_telemetry": "Mesh-telemetrie",
"privacy_mode_enabled": "Privacymodus (blokkeer extern HTTP/HTTPS)",
"privacy_mode_description": "Blokkeert kaarttegels, geocodering, LibreTranslate, firmware-downloads en andere uitgaande HTTP/HTTPS-verbindingen van de app. Het Content Security Policy van de browser wordt beperkt tot same-origin.",
+ "screen_security_enabled": "Schermbeveiliging (opname blokkeren)",
+ "screen_security_description": "Gebruikt een Windows display-affinity-vlag zodat MeshChatX wordt weggelaten uit screenshots, schermopnames en Windows Recall. Het venster ziet er op je monitor nog steeds normaal uit.",
+ "screen_security_description_short": "Verberg dit venster voor screenshots, recorders en Windows Recall.",
+ "screen_security_drm_eyebrow": "Windows-DRM / schermopnamebescherming",
+ "screen_security_drm_note": "Hergebruikt dezelfde OS-inhoudsbeschermingsvlag die media-apps voor DRM gebruiken. Dit is geen harde garantie: een camera op het scherm kan nog steeds inhoud vastleggen, en sommige toegankelijkheidshulpmiddelen hebben deze instelling uit nodig.",
+ "screen_security_disable_confirm": "Schermbeveiliging uitschakelen? Windows Recall en andere apps kunnen MeshChatX dan weer vastleggen.",
+ "screen_security_enabled_toast": "Schermbeveiliging ingeschakeld",
+ "screen_security_disabled_toast": "Schermbeveiliging uitgeschakeld",
"web_exposure_title": "Netwerkblootstelling",
"web_exposure_description": "Het bind-adres van de web-UI wordt bij opstarten ingesteld via --host of MESHCHAT_HOST. Beperk wie toegang heeft wanneer niet aan loopback gebonden.",
"web_listen_address": "Huidig bind-adres",
@@ -3315,6 +3323,9 @@
"ready": "Klaar om te rollen!",
"ready_desc": "Alles is geregeld. Reticulum is opnieuw gestart met uw nieuwe configuratie.",
"ready_desc_page": "MeshChatX is nu geconfigureerd en Reticulum is hot-reloaded met uw nieuwe instellingen.",
+ "privacy_title": "Privacy en beveiliging",
+ "privacy_desc": "Optionele verharding voor dit apparaat. Je kunt dit altijd wijzigen in Instellingen → Privacy.",
+ "privacy_later_hint": "Aanbevolen bij gevoelig gebruik. Opties uit laten staan is prima voor gewone installaties.",
"docker_note": "Reticulum werd automatisch herstart. Als er iets misgaat, kunt u MeshChatX opnieuw starten vanuit het menu.",
"restart_required": "Reticulum opnieuw gestart",
"restart_desc_page": "Reticulum werd opnieuw geladen met uw nieuwe configuratie. Voor normaal gebruik is geen handmatige herstart vereist.",
@@ -3735,5 +3746,11 @@
"identity_created": "Management identity created",
"failed_load_identities": "Failed to load management identities",
"failed_create_identity": "Failed to create management identity"
+ },
+ "post_install": {
+ "windows_screen_security_title": "Windows-schermbeveiliging inschakelen?",
+ "windows_screen_security_desc": "MeshChatX kan Windows-inhoudsbescherming gebruiken (dezelfde DRM-achtige displayvlag als media-apps) zodat screenshots, recorders en Windows Recall het appvenster niet vastleggen. Later te wijzigen onder Instellingen → Privacy.",
+ "windows_screen_security_enable": "Schermbeveiliging inschakelen",
+ "windows_screen_security_later": "Niet nu"
}
}
diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index 1c8d4ff6..046f29b8 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -138,6 +138,14 @@
"privacy_subsection_telemetry": "Телеметрия mesh",
"privacy_mode_enabled": "Режим конфиденциальности (блокировка внешнего HTTP/HTTPS)",
"privacy_mode_description": "Блокирует тайлы карт, геокодирование, LibreTranslate, загрузку прошивок и другие исходящие HTTP/HTTPS-соединения приложения. Политика безопасности контента браузера ограничивается только same-origin.",
+ "screen_security_enabled": "Защита экрана (блокировать захват)",
+ "screen_security_description": "Использует флаг display affinity Windows, чтобы MeshChatX не попадал в снимки экрана, запись и Windows Recall. Окно по-прежнему выглядит обычно на мониторе.",
+ "screen_security_description_short": "Скрыть это окно от снимков экрана, записи и Windows Recall.",
+ "screen_security_drm_eyebrow": "DRM Windows / защита от захвата экрана",
+ "screen_security_drm_note": "Использует тот же системный флаг защиты содержимого, что и медиаприложения для DRM. Это не жёсткая гарантия: камера, направленная на экран, всё ещё может захватить содержимое, а некоторым средствам доступности может понадобиться выключить эту настройку.",
+ "screen_security_disable_confirm": "Отключить защиту экрана? Windows Recall и другие приложения снова смогут захватывать MeshChatX.",
+ "screen_security_enabled_toast": "Защита экрана включена",
+ "screen_security_disabled_toast": "Защита экрана отключена",
"web_exposure_title": "Сетевая доступность",
"web_exposure_description": "Адрес привязки веб-интерфейса задаётся при запуске через --host или MESHCHAT_HOST. Ограничьте доступ, если привязка не к loopback.",
"web_listen_address": "Текущий адрес привязки",
@@ -3070,6 +3078,9 @@
"ready": "Все готово!",
"ready_desc": "Все настроено. Reticulum перезапущен с новой конфигурацией.",
"ready_desc_page": "MeshChatX настроен, Reticulum выполнил горячую перезагрузку с новыми параметрами.",
+ "privacy_title": "Конфиденциальность и безопасность",
+ "privacy_desc": "Необязательное усиление для этого устройства. Можно изменить в любой момент в Настройки → Конфиденциальность.",
+ "privacy_later_hint": "Рекомендуется при чувствительном использовании. Оставить параметры выключенными нормально для обычных установок.",
"docker_note": "Reticulum был перезапущен автоматически. Если что-то не так, перезапустите MeshChatX через меню.",
"restart_required": "Reticulum перезапущен",
"restart_desc_page": "Reticulum выполнил горячую перезагрузку с новой конфигурацией. Ручной перезапуск для обычной работы не требуется.",
@@ -3735,5 +3746,11 @@
"identity_created": "Management identity created",
"failed_load_identities": "Failed to load management identities",
"failed_create_identity": "Failed to create management identity"
+ },
+ "post_install": {
+ "windows_screen_security_title": "Включить защиту экрана Windows?",
+ "windows_screen_security_desc": "MeshChatX может использовать защиту содержимого Windows (тот же DRM-подобный флаг отображения, что у медиаприложений), чтобы снимки, запись и Windows Recall не захватывали окно. Позже можно изменить в Настройки → Конфиденциальность.",
+ "windows_screen_security_enable": "Включить защиту экрана",
+ "windows_screen_security_later": "Не сейчас"
}
}
diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json
index de3de67f..6322c02f 100644
--- a/meshchatx/src/frontend/locales/zh.json
+++ b/meshchatx/src/frontend/locales/zh.json
@@ -164,6 +164,14 @@
"privacy_subsection_telemetry": "Mesh 遥测",
"privacy_mode_enabled": "隐私模式(阻止外部 HTTP/HTTPS)",
"privacy_mode_description": "阻止地图瓦片、地理编码、LibreTranslate、固件下载以及应用的其他出站 HTTP/HTTPS 连接。浏览器内容安全策略收紧为仅同源。",
+ "screen_security_enabled": "屏幕安全(阻止截录)",
+ "screen_security_description": "使用 Windows 显示亲和性标志,使 MeshChatX 不出现在截图、录屏和 Windows Recall 中。窗口在显示器上仍正常显示。",
+ "screen_security_description_short": "对此窗口隐藏截图、录屏和 Windows Recall。",
+ "screen_security_drm_eyebrow": "Windows DRM / 屏幕捕获保护",
+ "screen_security_drm_note": "复用媒体应用用于 DRM 的同一系统内容保护标志。这不是绝对保证:对着屏幕拍照仍可捕获内容,部分辅助功能工具可能需要关闭此设置。",
+ "screen_security_disable_confirm": "关闭屏幕安全?Windows Recall 和其他应用将能再次捕获 MeshChatX。",
+ "screen_security_enabled_toast": "已启用屏幕安全",
+ "screen_security_disabled_toast": "已关闭屏幕安全",
"web_exposure_title": "网络暴露",
"web_exposure_description": "Web UI 绑定地址在启动时通过 --host 或 MESHCHAT_HOST 设置。未绑定到 loopback 时,请限制可访问者。",
"web_listen_address": "当前绑定地址",
@@ -3315,6 +3323,9 @@
"ready": "准备就绪!",
"ready_desc": "一切已设置。Reticulum 已随您的新配置重新启动。",
"ready_desc_page": "MeshChatX 现已配置,Reticulum 已随您的新设置热重载。",
+ "privacy_title": "隐私与安全",
+ "privacy_desc": "此设备的可选加固。可随时在 设置 → 隐私 中更改。",
+ "privacy_later_hint": "敏感用途建议开启。日常安装可保持关闭。",
"docker_note": "Reticulum 会自动重新启动。如果有任何更改,您可以从菜单中重新启动 MeshChatX。",
"restart_required": "Reticulum 重新启动",
"restart_desc_page": "Reticulum 已热重载您的新配置。正常使用不需要手动重启。",
@@ -3735,5 +3746,11 @@
"identity_created": "Management identity created",
"failed_load_identities": "Failed to load management identities",
"failed_create_identity": "Failed to create management identity"
+ },
+ "post_install": {
+ "windows_screen_security_title": "启用 Windows 屏幕安全?",
+ "windows_screen_security_desc": "MeshChatX 可使用 Windows 内容保护(与媒体应用相同的 DRM 风格显示标志),使截图、录屏和 Windows Recall 无法捕获应用窗口。之后可在 设置 → 隐私 中更改。",
+ "windows_screen_security_enable": "启用屏幕安全",
+ "windows_screen_security_later": "暂不"
}
}
diff --git a/tests/electron/desktopPrivacySettings.test.js b/tests/electron/desktopPrivacySettings.test.js
new file mode 100644
index 00000000..95aac31e
--- /dev/null
+++ b/tests/electron/desktopPrivacySettings.test.js
@@ -0,0 +1,147 @@
+// SPDX-License-Identifier: 0BSD
+
+import { createRequire } from "module";
+import path from "path";
+import fs from "fs";
+import os from "os";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+const require = createRequire(import.meta.url);
+const {
+ defaultDesktopPrivacySettings,
+ normalizeDesktopPrivacySettings,
+ loadDesktopPrivacySettings,
+ saveDesktopPrivacySettings,
+ isScreenSecurityPlatformSupported,
+ applyContentProtection,
+ applyContentProtectionToWindows,
+ desktopPrivacySettingsPath,
+} = require("../../electron/desktopPrivacySettings.js");
+
+describe("electron/desktopPrivacySettings", () => {
+ const tempDirs = [];
+
+ afterEach(() => {
+ for (const dir of tempDirs.splice(0)) {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ it("defaults screen security off", () => {
+ expect(defaultDesktopPrivacySettings()).toEqual({
+ screenSecurityEnabled: false,
+ });
+ });
+
+ it("normalizes invalid and missing values", () => {
+ expect(normalizeDesktopPrivacySettings(null)).toEqual(defaultDesktopPrivacySettings());
+ expect(normalizeDesktopPrivacySettings([])).toEqual(defaultDesktopPrivacySettings());
+ expect(normalizeDesktopPrivacySettings({ screenSecurityEnabled: "yes" })).toEqual(
+ defaultDesktopPrivacySettings()
+ );
+ expect(normalizeDesktopPrivacySettings({ screenSecurityEnabled: 1 })).toEqual(
+ defaultDesktopPrivacySettings()
+ );
+ expect(normalizeDesktopPrivacySettings({ screenSecurityEnabled: true })).toEqual({
+ screenSecurityEnabled: true,
+ });
+ expect(normalizeDesktopPrivacySettings({ screenSecurityEnabled: false, extra: 1 })).toEqual({
+ screenSecurityEnabled: false,
+ });
+ });
+
+ it("persists and reloads settings from storage dir", () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "meshchatx-privacy-"));
+ tempDirs.push(dir);
+ const saved = saveDesktopPrivacySettings(dir, { screenSecurityEnabled: true });
+ expect(saved).toEqual({ screenSecurityEnabled: true });
+ expect(loadDesktopPrivacySettings(dir)).toEqual({ screenSecurityEnabled: true });
+ expect(fs.existsSync(desktopPrivacySettingsPath(dir))).toBe(true);
+ });
+
+ it("returns defaults for missing corrupt and empty files", () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "meshchatx-privacy-"));
+ tempDirs.push(dir);
+ expect(loadDesktopPrivacySettings(dir)).toEqual(defaultDesktopPrivacySettings());
+
+ fs.writeFileSync(desktopPrivacySettingsPath(dir), "not-json", "utf8");
+ expect(loadDesktopPrivacySettings(dir)).toEqual(defaultDesktopPrivacySettings());
+
+ fs.writeFileSync(desktopPrivacySettingsPath(dir), JSON.stringify(["nope"]), "utf8");
+ expect(loadDesktopPrivacySettings(dir)).toEqual(defaultDesktopPrivacySettings());
+
+ fs.writeFileSync(desktopPrivacySettingsPath(dir), JSON.stringify({ screenSecurityEnabled: "x" }), "utf8");
+ expect(loadDesktopPrivacySettings(dir)).toEqual(defaultDesktopPrivacySettings());
+ });
+
+ it("ignores non-object partials and preserves current state", () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "meshchatx-privacy-"));
+ tempDirs.push(dir);
+ saveDesktopPrivacySettings(dir, { screenSecurityEnabled: true });
+ expect(saveDesktopPrivacySettings(dir, null)).toEqual({ screenSecurityEnabled: true });
+ expect(saveDesktopPrivacySettings(dir, "nope")).toEqual({ screenSecurityEnabled: true });
+ expect(saveDesktopPrivacySettings(dir, {})).toEqual({ screenSecurityEnabled: true });
+ });
+
+ it("last write wins under concurrent save races", () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "meshchatx-privacy-race-"));
+ tempDirs.push(dir);
+ const results = [];
+ for (let i = 0; i < 20; i += 1) {
+ results.push(saveDesktopPrivacySettings(dir, { screenSecurityEnabled: i % 2 === 0 }));
+ }
+ const final = loadDesktopPrivacySettings(dir);
+ expect(results[results.length - 1]).toEqual(final);
+ expect(typeof final.screenSecurityEnabled).toBe("boolean");
+ });
+
+ it("reports supported platforms", () => {
+ expect(isScreenSecurityPlatformSupported("win32")).toBe(true);
+ expect(isScreenSecurityPlatformSupported("darwin")).toBe(true);
+ expect(isScreenSecurityPlatformSupported("linux")).toBe(false);
+ expect(isScreenSecurityPlatformSupported("")).toBe(false);
+ expect(isScreenSecurityPlatformSupported(null)).toBe(false);
+ expect(isScreenSecurityPlatformSupported(undefined)).toBe(false);
+ });
+
+ it("applyContentProtection skips null destroyed and missing APIs", () => {
+ expect(applyContentProtection(null, true)).toBe(false);
+ expect(applyContentProtection({ isDestroyed: () => true, setContentProtection: vi.fn() }, true)).toBe(false);
+ expect(applyContentProtection({ isDestroyed: () => false }, true)).toBe(false);
+
+ const setContentProtection = vi.fn();
+ expect(
+ applyContentProtection(
+ {
+ isDestroyed: () => false,
+ setContentProtection,
+ },
+ true
+ )
+ ).toBe(true);
+ expect(setContentProtection).toHaveBeenCalledWith(true);
+
+ const throwing = vi.fn(() => {
+ throw new Error("boom");
+ });
+ expect(
+ applyContentProtection(
+ {
+ isDestroyed: () => false,
+ setContentProtection: throwing,
+ },
+ false
+ )
+ ).toBe(false);
+ });
+
+ it("applyContentProtectionToWindows counts only successful applies", () => {
+ const ok = { isDestroyed: () => false, setContentProtection: vi.fn() };
+ const destroyed = { isDestroyed: () => true, setContentProtection: vi.fn() };
+ const missing = { isDestroyed: () => false };
+ expect(applyContentProtectionToWindows(null, true)).toBe(0);
+ expect(applyContentProtectionToWindows([ok, destroyed, missing, null], true)).toBe(1);
+ expect(ok.setContentProtection).toHaveBeenCalledWith(true);
+ expect(destroyed.setContentProtection).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/electron/preload.test.js b/tests/electron/preload.test.js
index 51d3443f..c606a10e 100644
--- a/tests/electron/preload.test.js
+++ b/tests/electron/preload.test.js
@@ -125,6 +125,45 @@ describe("electron/preload", () => {
expect(invoke).toHaveBeenCalledWith("set-close-settings", { closeBehavior: "quit" });
});
+ it("exposes screen security IPC helpers and platform", async () => {
+ const exposeInMainWorld = vi.fn();
+ const invoke = vi.fn();
+ loadPreloadWithElectronMock({
+ contextBridge: { exposeInMainWorld },
+ ipcRenderer: { invoke, on: vi.fn() },
+ });
+ const api = exposeInMainWorld.mock.calls[0][1];
+ expect(typeof api.getPlatform).toBe("function");
+
+ invoke.mockResolvedValueOnce({
+ platform: "win32",
+ available: true,
+ windowsDrm: true,
+ enabled: false,
+ });
+ await expect(api.getScreenSecuritySettings()).resolves.toEqual({
+ platform: "win32",
+ available: true,
+ windowsDrm: true,
+ enabled: false,
+ });
+ expect(invoke).toHaveBeenCalledWith("get-screen-security-settings");
+
+ invoke.mockResolvedValueOnce({
+ platform: "win32",
+ available: true,
+ windowsDrm: true,
+ enabled: true,
+ });
+ await expect(api.setScreenSecurityEnabled(true)).resolves.toEqual({
+ platform: "win32",
+ available: true,
+ windowsDrm: true,
+ enabled: true,
+ });
+ expect(invoke).toHaveBeenCalledWith("set-screen-security-enabled", true);
+ });
+
it("subscribes to log channel on load", () => {
const exposeInMainWorld = vi.fn();
const on = vi.fn();
diff --git a/tests/frontend/CommandPalette.test.js b/tests/frontend/CommandPalette.test.js
index 453620d0..d1678ec6 100644
--- a/tests/frontend/CommandPalette.test.js
+++ b/tests/frontend/CommandPalette.test.js
@@ -10,6 +10,7 @@ import {
registerCoreContributions,
resetCoreContributionsForTests,
} from "../../meshchatx/src/frontend/js/registries/registerCoreContributions.js";
+import { postInstallPromptRegistry } from "../../meshchatx/src/frontend/js/registries/postInstallPromptRegistry.js";
describe("CommandPalette.vue", () => {
let axiosMock;
@@ -21,6 +22,7 @@ describe("CommandPalette.vue", () => {
toolsRegistry.clear();
commandRegistry.clear();
settingsSectionRegistry.clear();
+ postInstallPromptRegistry.clear();
registerCoreContributions();
axiosMock = {
get: vi.fn().mockResolvedValue({
diff --git a/tests/frontend/ElectronUtils.screenSecurity.test.js b/tests/frontend/ElectronUtils.screenSecurity.test.js
new file mode 100644
index 00000000..374cdca0
--- /dev/null
+++ b/tests/frontend/ElectronUtils.screenSecurity.test.js
@@ -0,0 +1,54 @@
+// SPDX-License-Identifier: 0BSD
+
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import ElectronUtils from "../../meshchatx/src/frontend/js/ElectronUtils.js";
+
+describe("ElectronUtils screen security edge cases", () => {
+ afterEach(() => {
+ delete window.electron;
+ vi.restoreAllMocks();
+ });
+
+ it("returns nulls when electron bridge is missing", async () => {
+ expect(ElectronUtils.isElectron()).toBe(false);
+ expect(ElectronUtils.getPlatform()).toBeNull();
+ expect(ElectronUtils.isWindowsElectron()).toBe(false);
+ await expect(ElectronUtils.getScreenSecuritySettings()).resolves.toBeNull();
+ await expect(ElectronUtils.setScreenSecurityEnabled(true)).resolves.toBeNull();
+ });
+
+ it("isWindowsElectron requires both electron and win32", () => {
+ window.electron = {
+ getPlatform: () => "linux",
+ };
+ expect(ElectronUtils.isWindowsElectron()).toBe(false);
+ window.electron.getPlatform = () => "win32";
+ expect(ElectronUtils.isWindowsElectron()).toBe(true);
+ });
+
+ it("coerces setScreenSecurityEnabled to a strict boolean for the IPC bridge", async () => {
+ const setScreenSecurityEnabled = vi.fn(async (enabled) => ({ enabled }));
+ window.electron = {
+ getPlatform: () => "win32",
+ setScreenSecurityEnabled,
+ getScreenSecuritySettings: vi.fn(async () => ({ enabled: false })),
+ };
+ await ElectronUtils.setScreenSecurityEnabled(true);
+ expect(setScreenSecurityEnabled).toHaveBeenCalledWith(true);
+ await ElectronUtils.setScreenSecurityEnabled("yes");
+ expect(setScreenSecurityEnabled).toHaveBeenCalledWith(false);
+ await ElectronUtils.setScreenSecurityEnabled(0);
+ expect(setScreenSecurityEnabled).toHaveBeenCalledWith(false);
+ await ElectronUtils.setScreenSecurityEnabled(null);
+ expect(setScreenSecurityEnabled).toHaveBeenCalledWith(false);
+ });
+
+ it("tolerates missing optional bridge methods", async () => {
+ window.electron = {
+ getPlatform: () => "win32",
+ };
+ expect(ElectronUtils.isWindowsElectron()).toBe(true);
+ await expect(ElectronUtils.getScreenSecuritySettings()).resolves.toBeNull();
+ await expect(ElectronUtils.setScreenSecurityEnabled(true)).resolves.toBeNull();
+ });
+});
diff --git a/tests/frontend/InterfacesDiscoveryActions.test.js b/tests/frontend/InterfacesDiscoveryActions.test.js
index 82d72b05..630cb299 100644
--- a/tests/frontend/InterfacesDiscoveryActions.test.js
+++ b/tests/frontend/InterfacesDiscoveryActions.test.js
@@ -31,6 +31,7 @@ vi.mock("../../meshchatx/src/frontend/js/ElectronUtils", () => ({
default: {
relaunch: vi.fn(),
isElectron: () => false,
+ isWindowsElectron: () => false,
},
}));
diff --git a/tests/frontend/InterfacesDiscoveryIfac.test.js b/tests/frontend/InterfacesDiscoveryIfac.test.js
index 7b2a32e0..0bbe41e3 100644
--- a/tests/frontend/InterfacesDiscoveryIfac.test.js
+++ b/tests/frontend/InterfacesDiscoveryIfac.test.js
@@ -39,6 +39,7 @@ vi.mock("../../meshchatx/src/frontend/js/ElectronUtils", () => ({
default: {
relaunch: vi.fn(),
isElectron: () => false,
+ isWindowsElectron: () => false,
},
}));
diff --git a/tests/frontend/InterfacesPage.stats.test.js b/tests/frontend/InterfacesPage.stats.test.js
index 985da3c4..d3036b09 100644
--- a/tests/frontend/InterfacesPage.stats.test.js
+++ b/tests/frontend/InterfacesPage.stats.test.js
@@ -30,6 +30,7 @@ vi.mock("../../meshchatx/src/frontend/js/ElectronUtils", () => ({
default: {
relaunch: vi.fn(),
isElectron: () => false,
+ isWindowsElectron: () => false,
},
}));
diff --git a/tests/frontend/InterfacesPerformance.test.js b/tests/frontend/InterfacesPerformance.test.js
index 3d6fc4af..9a3524be 100644
--- a/tests/frontend/InterfacesPerformance.test.js
+++ b/tests/frontend/InterfacesPerformance.test.js
@@ -28,6 +28,8 @@ vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({
vi.mock("../../meshchatx/src/frontend/js/ElectronUtils", () => ({
default: {
relaunch: vi.fn(),
+ isElectron: () => false,
+ isWindowsElectron: () => false,
},
}));
diff --git a/tests/frontend/SettingsPage.config-persistence.test.js b/tests/frontend/SettingsPage.config-persistence.test.js
index b14d6a84..89b018ae 100644
--- a/tests/frontend/SettingsPage.config-persistence.test.js
+++ b/tests/frontend/SettingsPage.config-persistence.test.js
@@ -43,6 +43,11 @@ vi.mock("../../meshchatx/src/frontend/js/KeyboardShortcuts", () => ({
vi.mock("../../meshchatx/src/frontend/js/ElectronUtils", () => ({
default: {
isElectron: vi.fn(() => false),
+ isWindowsElectron: vi.fn(() => false),
+ getScreenSecuritySettings: vi.fn(async () => null),
+ setScreenSecurityEnabled: vi.fn(async () => null),
+ getCloseSettings: vi.fn(async () => null),
+ setCloseSettings: vi.fn(async () => null),
},
}));
diff --git a/tests/frontend/SettingsPage.screenSecurity.test.js b/tests/frontend/SettingsPage.screenSecurity.test.js
new file mode 100644
index 00000000..76d96d37
--- /dev/null
+++ b/tests/frontend/SettingsPage.screenSecurity.test.js
@@ -0,0 +1,225 @@
+// SPDX-License-Identifier: 0BSD
+
+import { mount, flushPromises } from "@vue/test-utils";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import SettingsPage from "../../meshchatx/src/frontend/components/settings/SettingsPage.vue";
+import Toggle from "../../meshchatx/src/frontend/components/forms/Toggle.vue";
+import DialogUtils from "../../meshchatx/src/frontend/js/DialogUtils";
+import ToastUtils from "../../meshchatx/src/frontend/js/ToastUtils";
+import ElectronUtils from "../../meshchatx/src/frontend/js/ElectronUtils";
+import { buildFullServerConfig, createWindowApi } from "./fixtures/settingsPageTestApi.js";
+
+vi.mock("../../meshchatx/src/frontend/js/WebSocketConnection", () => ({
+ default: {
+ on: vi.fn(),
+ off: vi.fn(),
+ emit: vi.fn(),
+ send: vi.fn(),
+ },
+}));
+
+vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({
+ default: {
+ success: vi.fn(),
+ error: vi.fn(),
+ warning: vi.fn(),
+ info: vi.fn(),
+ loading: vi.fn(),
+ dismiss: vi.fn(),
+ },
+}));
+
+vi.mock("../../meshchatx/src/frontend/js/DialogUtils", () => ({
+ default: {
+ confirm: vi.fn().mockResolvedValue(true),
+ },
+}));
+
+vi.mock("../../meshchatx/src/frontend/js/KeyboardShortcuts", () => ({
+ default: {
+ getDefaultShortcuts: vi.fn(() => []),
+ send: vi.fn(),
+ },
+}));
+
+vi.mock("../../meshchatx/src/frontend/js/ElectronUtils", () => ({
+ default: {
+ isElectron: vi.fn(() => true),
+ isWindowsElectron: vi.fn(() => true),
+ getScreenSecuritySettings: vi.fn(async () => ({
+ platform: "win32",
+ available: true,
+ windowsDrm: true,
+ enabled: false,
+ })),
+ setScreenSecurityEnabled: vi.fn(async (enabled) => ({
+ platform: "win32",
+ available: true,
+ windowsDrm: true,
+ enabled: enabled === true,
+ })),
+ getCloseSettings: vi.fn(async () => ({ closeBehavior: "ask", trayEnabled: true })),
+ setCloseSettings: vi.fn(async (partial) => ({
+ closeBehavior: "ask",
+ trayEnabled: true,
+ ...partial,
+ })),
+ },
+}));
+
+async function mountSettingsPage() {
+ const api = createWindowApi(buildFullServerConfig());
+ window.api = api;
+ const wrapper = mount(SettingsPage, {
+ global: {
+ stubs: {
+ MaterialDesignIcon: { template: "<span class='mdi'></span>" },
+ Toggle,
+ SettingsNav: true,
+ LanguageSelector: true,
+ PluginSlotNode: true,
+ },
+ mocks: {
+ $t: (key) => key,
+ $router: { push: vi.fn() },
+ $route: { name: "settings", query: {} },
+ },
+ },
+ });
+ await flushPromises();
+ return wrapper;
+}
+
+describe("SettingsPage screen security edge and race", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ ElectronUtils.isWindowsElectron.mockReturnValue(true);
+ ElectronUtils.getScreenSecuritySettings.mockResolvedValue({
+ platform: "win32",
+ available: true,
+ windowsDrm: true,
+ enabled: false,
+ });
+ ElectronUtils.setScreenSecurityEnabled.mockImplementation(async (enabled) => ({
+ platform: "win32",
+ available: true,
+ windowsDrm: true,
+ enabled: enabled === true,
+ }));
+ DialogUtils.confirm.mockResolvedValue(true);
+ });
+
+ afterEach(() => {
+ delete window.api;
+ });
+
+ it("loads Windows screen security state on mount", async () => {
+ const wrapper = await mountSettingsPage();
+ expect(wrapper.vm.showWindowsScreenSecurity).toBe(true);
+ expect(wrapper.vm.screenSecurityEnabled).toBe(false);
+ expect(ElectronUtils.getScreenSecuritySettings).toHaveBeenCalled();
+ wrapper.unmount();
+ });
+
+ it("hides screen security when not Windows Electron", async () => {
+ ElectronUtils.isWindowsElectron.mockReturnValue(false);
+ const wrapper = await mountSettingsPage();
+ expect(wrapper.vm.showWindowsScreenSecurity).toBe(false);
+ expect(ElectronUtils.getScreenSecuritySettings).not.toHaveBeenCalled();
+ wrapper.unmount();
+ });
+
+ it("tolerates getScreenSecuritySettings failures", async () => {
+ ElectronUtils.getScreenSecuritySettings.mockRejectedValue(new Error("ipc"));
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
+ const wrapper = await mountSettingsPage();
+ expect(wrapper.vm.showWindowsScreenSecurity).toBe(true);
+ expect(wrapper.vm.screenSecurityEnabled).toBe(false);
+ logSpy.mockRestore();
+ wrapper.unmount();
+ });
+
+ it("cancels disable when confirm is rejected", async () => {
+ ElectronUtils.getScreenSecuritySettings.mockResolvedValue({
+ enabled: true,
+ available: true,
+ });
+ DialogUtils.confirm.mockResolvedValue(false);
+ const wrapper = await mountSettingsPage();
+ expect(wrapper.vm.screenSecurityEnabled).toBe(true);
+
+ await wrapper.vm.onScreenSecurityChange(false);
+ expect(DialogUtils.confirm).toHaveBeenCalled();
+ expect(ElectronUtils.setScreenSecurityEnabled).not.toHaveBeenCalled();
+ expect(wrapper.vm.screenSecurityEnabled).toBe(true);
+ wrapper.unmount();
+ });
+
+ it("dedupes concurrent enable toggles (race)", async () => {
+ let resolveSet;
+ ElectronUtils.setScreenSecurityEnabled.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveSet = resolve;
+ })
+ );
+ const wrapper = await mountSettingsPage();
+
+ const first = wrapper.vm.onScreenSecurityChange(true);
+ const second = wrapper.vm.onScreenSecurityChange(true);
+ expect(ElectronUtils.setScreenSecurityEnabled).toHaveBeenCalledTimes(1);
+
+ resolveSet({ enabled: true, available: true, platform: "win32", windowsDrm: true });
+ await Promise.all([first, second]);
+ expect(wrapper.vm.screenSecurityEnabled).toBe(true);
+ expect(wrapper.vm.screenSecuritySaving).toBe(false);
+ wrapper.unmount();
+ });
+
+ it("holds the race lock across disable confirm", async () => {
+ let resolveConfirm;
+ DialogUtils.confirm.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveConfirm = resolve;
+ })
+ );
+ ElectronUtils.getScreenSecuritySettings.mockResolvedValue({
+ enabled: true,
+ available: true,
+ });
+ const wrapper = await mountSettingsPage();
+
+ const first = wrapper.vm.onScreenSecurityChange(false);
+ const second = wrapper.vm.onScreenSecurityChange(false);
+ expect(DialogUtils.confirm).toHaveBeenCalledTimes(1);
+
+ resolveConfirm(true);
+ await flushPromises();
+ await Promise.all([first, second]);
+ expect(ElectronUtils.setScreenSecurityEnabled).toHaveBeenCalledTimes(1);
+ expect(ElectronUtils.setScreenSecurityEnabled).toHaveBeenCalledWith(false);
+ wrapper.unmount();
+ });
+
+ it("rolls back and toasts when setScreenSecurityEnabled fails", async () => {
+ ElectronUtils.setScreenSecurityEnabled.mockRejectedValue(new Error("fail"));
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
+ const wrapper = await mountSettingsPage();
+ await wrapper.vm.onScreenSecurityChange(true);
+ expect(wrapper.vm.screenSecurityEnabled).toBe(false);
+ expect(ToastUtils.error).toHaveBeenCalled();
+ logSpy.mockRestore();
+ wrapper.unmount();
+ });
+
+ it("tolerates missing isWindowsElectron on ElectronUtils mock", async () => {
+ const original = ElectronUtils.isWindowsElectron;
+ // @ts-ignore
+ ElectronUtils.isWindowsElectron = undefined;
+ const wrapper = await mountSettingsPage();
+ expect(wrapper.vm.showWindowsScreenSecurity).toBe(false);
+ ElectronUtils.isWindowsElectron = original;
+ wrapper.unmount();
+ });
+});
diff --git a/tests/frontend/TutorialPrivacyStep.test.js b/tests/frontend/TutorialPrivacyStep.test.js
new file mode 100644
index 00000000..91892d3a
--- /dev/null
+++ b/tests/frontend/TutorialPrivacyStep.test.js
@@ -0,0 +1,306 @@
+// SPDX-License-Identifier: 0BSD
+
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { mount, flushPromises } from "@vue/test-utils";
+import { createI18n } from "vue-i18n";
+import { createVuetify } from "vuetify";
+import TutorialPrivacyStep from "../../meshchatx/src/frontend/components/TutorialPrivacyStep.vue";
+import en from "../../meshchatx/src/frontend/locales/en.json";
+import ElectronUtils from "../../meshchatx/src/frontend/js/ElectronUtils.js";
+import { CORE_POST_INSTALL_PROMPT_ENTRIES } from "../../meshchatx/src/frontend/js/registries/corePostInstallPromptEntries.js";
+import ToastUtils from "../../meshchatx/src/frontend/js/ToastUtils";
+
+vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({
+ default: {
+ success: vi.fn(),
+ error: vi.fn(),
+ warning: vi.fn(),
+ info: vi.fn(),
+ },
+}));
+
+const vuetify = createVuetify();
+const i18n = createI18n({
+ legacy: false,
+ locale: "en",
+ messages: { en },
+});
+
+function mountPrivacyStep() {
+ return mount(TutorialPrivacyStep, {
+ global: {
+ plugins: [i18n, vuetify],
+ stubs: { Toggle: true },
+ },
+ });
+}
+
+describe("TutorialPrivacyStep", () => {
+ beforeEach(() => {
+ window.api = {
+ get: vi.fn(async (url) => {
+ if (url === "/api/v1/config") {
+ return {
+ data: {
+ config: {
+ privacy_mode_enabled: false,
+ telemetry_enabled: true,
+ },
+ },
+ };
+ }
+ if (url === "/api/v1/reticulum/instance") {
+ return {
+ data: {
+ instance: {
+ local_hops_delta: false,
+ },
+ },
+ };
+ }
+ return { data: {} };
+ }),
+ patch: vi.fn(async () => ({ data: {} })),
+ };
+ vi.spyOn(ElectronUtils, "isWindowsElectron").mockReturnValue(false);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ delete window.api;
+ delete window.electron;
+ });
+
+ it("loads privacy toggles and patches obfuscate hops", async () => {
+ const wrapper = mountPrivacyStep();
+ await flushPromises();
+ expect(wrapper.vm.localHopsDelta).toBe(false);
+ expect(wrapper.vm.privacyModeEnabled).toBe(false);
+ expect(wrapper.vm.telemetryEnabled).toBe(true);
+ expect(wrapper.vm.showWindowsScreenSecurity).toBe(false);
+
+ await wrapper.vm.onLocalHopsDeltaChange(true);
+ expect(window.api.patch).toHaveBeenCalledWith("/api/v1/reticulum/instance", {
+ local_hops_delta: true,
+ });
+ expect(wrapper.vm.localHopsDelta).toBe(true);
+ });
+
+ it("shows Windows DRM screen security when on Windows Electron", async () => {
+ ElectronUtils.isWindowsElectron.mockReturnValue(true);
+ vi.spyOn(ElectronUtils, "getScreenSecuritySettings").mockResolvedValue({
+ platform: "win32",
+ available: true,
+ windowsDrm: true,
+ enabled: false,
+ });
+ vi.spyOn(ElectronUtils, "setScreenSecurityEnabled").mockResolvedValue({
+ platform: "win32",
+ available: true,
+ windowsDrm: true,
+ enabled: true,
+ });
+
+ const wrapper = mountPrivacyStep();
+ await flushPromises();
+ expect(wrapper.vm.showWindowsScreenSecurity).toBe(true);
+ expect(wrapper.text()).toContain("Windows DRM");
+
+ await wrapper.vm.onScreenSecurityChange(true);
+ expect(ElectronUtils.setScreenSecurityEnabled).toHaveBeenCalledWith(true);
+ expect(ToastUtils.success).toHaveBeenCalled();
+ expect(wrapper.vm.screenSecurityEnabled).toBe(true);
+ });
+
+ it("tolerates load failures and missing payload fields", async () => {
+ window.api.get = vi.fn(async () => {
+ throw new Error("offline");
+ });
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
+ const wrapper = mountPrivacyStep();
+ await flushPromises();
+ expect(wrapper.vm.privacyModeEnabled).toBe(false);
+ expect(wrapper.vm.localHopsDelta).toBe(false);
+ expect(wrapper.vm.telemetryEnabled).toBe(false);
+ errSpy.mockRestore();
+ });
+
+ it("rolls back obfuscate hops when patch fails", async () => {
+ const wrapper = mountPrivacyStep();
+ await flushPromises();
+ window.api.patch = vi.fn(async () => {
+ throw new Error("patch failed");
+ });
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
+ await wrapper.vm.onLocalHopsDeltaChange(true);
+ expect(wrapper.vm.localHopsDelta).toBe(false);
+ expect(ToastUtils.error).toHaveBeenCalled();
+ errSpy.mockRestore();
+ });
+
+ it("dedupes concurrent screen-security toggles (race)", async () => {
+ ElectronUtils.isWindowsElectron.mockReturnValue(true);
+ vi.spyOn(ElectronUtils, "getScreenSecuritySettings").mockResolvedValue({
+ enabled: false,
+ available: true,
+ });
+ let resolveSet;
+ const setSpy = vi.spyOn(ElectronUtils, "setScreenSecurityEnabled").mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveSet = resolve;
+ })
+ );
+
+ const wrapper = mountPrivacyStep();
+ await flushPromises();
+
+ const first = wrapper.vm.onScreenSecurityChange(true);
+ const second = wrapper.vm.onScreenSecurityChange(false);
+ expect(setSpy).toHaveBeenCalledTimes(1);
+
+ resolveSet({ enabled: true });
+ await Promise.all([first, second]);
+ expect(wrapper.vm.screenSecurityEnabled).toBe(true);
+ expect(wrapper.vm.screenSecuritySaving).toBe(false);
+ });
+
+ it("dedupes concurrent obfuscate-hops patches (race)", async () => {
+ const wrapper = mountPrivacyStep();
+ await flushPromises();
+
+ let resolvePatch;
+ window.api.patch = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ resolvePatch = resolve;
+ })
+ );
+
+ const first = wrapper.vm.onLocalHopsDeltaChange(true);
+ const second = wrapper.vm.onLocalHopsDeltaChange(false);
+ expect(window.api.patch).toHaveBeenCalledTimes(1);
+
+ resolvePatch({ data: {} });
+ await Promise.all([first, second]);
+ expect(wrapper.vm.localHopsDelta).toBe(true);
+ expect(wrapper.vm.reticulumSaving).toBe(false);
+ });
+
+ it("dedupes concurrent privacy-mode and telemetry patches sharing configSaving", async () => {
+ const wrapper = mountPrivacyStep();
+ await flushPromises();
+
+ let resolvePatch;
+ window.api.patch = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ resolvePatch = resolve;
+ })
+ );
+
+ const privacy = wrapper.vm.onPrivacyModeChange(true);
+ const telemetry = wrapper.vm.onTelemetryChange(false);
+ expect(window.api.patch).toHaveBeenCalledTimes(1);
+ expect(window.api.patch.mock.calls[0][1]).toEqual({ privacy_mode_enabled: true });
+
+ resolvePatch({ data: {} });
+ await Promise.all([privacy, telemetry]);
+ expect(wrapper.vm.privacyModeEnabled).toBe(true);
+ expect(wrapper.vm.telemetryEnabled).toBe(true);
+ expect(wrapper.vm.configSaving).toBe(false);
+ });
+
+ it("rolls back screen security when set fails", async () => {
+ ElectronUtils.isWindowsElectron.mockReturnValue(true);
+ vi.spyOn(ElectronUtils, "getScreenSecuritySettings").mockResolvedValue({ enabled: false });
+ vi.spyOn(ElectronUtils, "setScreenSecurityEnabled").mockRejectedValue(new Error("ipc down"));
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
+
+ const wrapper = mountPrivacyStep();
+ await flushPromises();
+ await wrapper.vm.onScreenSecurityChange(true);
+ expect(wrapper.vm.screenSecurityEnabled).toBe(false);
+ expect(ToastUtils.error).toHaveBeenCalled();
+ errSpy.mockRestore();
+ });
+});
+
+describe("windows_screen_security post-install prompt", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("is registered with enable/later actions", () => {
+ const entry = CORE_POST_INSTALL_PROMPT_ENTRIES.find((e) => e.id === "windows_screen_security");
+ expect(entry).toBeTruthy();
+ expect(entry.revision).toBe(1);
+ expect(entry.titleKey).toBe("post_install.windows_screen_security_title");
+ expect(entry.primaryLabelKey).toBe("post_install.windows_screen_security_enable");
+ expect(entry.secondaryLabelKey).toBe("post_install.windows_screen_security_later");
+ });
+
+ it("shouldShow is false off Windows Electron", async () => {
+ const entry = CORE_POST_INSTALL_PROMPT_ENTRIES.find((e) => e.id === "windows_screen_security");
+ vi.spyOn(ElectronUtils, "isWindowsElectron").mockReturnValue(false);
+ await expect(entry.shouldShow()).resolves.toBe(false);
+ });
+
+ it("shouldShow is true when Windows Electron and disabled", async () => {
+ const entry = CORE_POST_INSTALL_PROMPT_ENTRIES.find((e) => e.id === "windows_screen_security");
+ vi.spyOn(ElectronUtils, "isWindowsElectron").mockReturnValue(true);
+ vi.spyOn(ElectronUtils, "getScreenSecuritySettings").mockResolvedValue({
+ available: true,
+ enabled: false,
+ });
+ await expect(entry.shouldShow()).resolves.toBe(true);
+ });
+
+ it("shouldShow is false when already enabled or unavailable", async () => {
+ const entry = CORE_POST_INSTALL_PROMPT_ENTRIES.find((e) => e.id === "windows_screen_security");
+ vi.spyOn(ElectronUtils, "isWindowsElectron").mockReturnValue(true);
+ vi.spyOn(ElectronUtils, "getScreenSecuritySettings").mockResolvedValue({
+ available: true,
+ enabled: true,
+ });
+ await expect(entry.shouldShow()).resolves.toBe(false);
+
+ ElectronUtils.getScreenSecuritySettings.mockResolvedValue({
+ available: false,
+ enabled: false,
+ });
+ await expect(entry.shouldShow()).resolves.toBe(false);
+ });
+
+ it("shouldShow is false when settings lookup throws", async () => {
+ const entry = CORE_POST_INSTALL_PROMPT_ENTRIES.find((e) => e.id === "windows_screen_security");
+ vi.spyOn(ElectronUtils, "isWindowsElectron").mockReturnValue(true);
+ vi.spyOn(ElectronUtils, "getScreenSecuritySettings").mockRejectedValue(new Error("boom"));
+ await expect(entry.shouldShow()).resolves.toBe(false);
+ });
+
+ it("onPrimary enables screen security", async () => {
+ const entry = CORE_POST_INSTALL_PROMPT_ENTRIES.find((e) => e.id === "windows_screen_security");
+ const setSpy = vi.spyOn(ElectronUtils, "setScreenSecurityEnabled").mockResolvedValue({ enabled: true });
+ await expect(entry.onPrimary()).resolves.toBe(true);
+ expect(setSpy).toHaveBeenCalledWith(true);
+ });
+
+ it("onPrimary returns false when enable fails", async () => {
+ const entry = CORE_POST_INSTALL_PROMPT_ENTRIES.find((e) => e.id === "windows_screen_security");
+ vi.spyOn(ElectronUtils, "setScreenSecurityEnabled").mockRejectedValue(new Error("fail"));
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
+ await expect(entry.onPrimary()).resolves.toBe(false);
+ errSpy.mockRestore();
+ });
+
+ it("shouldShow tolerates missing isWindowsElectron helper", async () => {
+ const entry = CORE_POST_INSTALL_PROMPT_ENTRIES.find((e) => e.id === "windows_screen_security");
+ const original = ElectronUtils.isWindowsElectron;
+ // Simulate partial ElectronUtils mock used in other tests.
+ // @ts-ignore
+ ElectronUtils.isWindowsElectron = undefined;
+ await expect(entry.shouldShow()).resolves.toBe(false);
+ ElectronUtils.isWindowsElectron = original;
+ });
+});
diff --git a/tests/frontend/UIComponents.test.js b/tests/frontend/UIComponents.test.js
index 08f448be..d32334fb 100644
--- a/tests/frontend/UIComponents.test.js
+++ b/tests/frontend/UIComponents.test.js
@@ -45,6 +45,7 @@ vi.mock("../../meshchatx/src/frontend/js/KeyboardShortcuts", () => ({
vi.mock("../../meshchatx/src/frontend/js/ElectronUtils", () => ({
default: {
isElectron: vi.fn(() => false),
+ isWindowsElectron: vi.fn(() => false),
},
}));
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────